// MTUI "server" - this just acts as the backend for mtui, controlling the
// player, queue, etc. It's entirely independent from tui-lib/UI.

'use strict'

import {readFile, writeFile} from 'node:fs/promises'
import EventEmitter from 'node:events'
import os from 'node:os'

import {getDownloaderFor} from './downloaders.js'
import {getMetadataReaderFor} from './metadata-readers.js'
import {getPlayer} from './players.js'
import RecordStore from './record-store.js'

import {
  getTimeStringsFromSec,
  shuffleArray,
  throttlePromise,
} from './general-util.js'

import {
  isGroup,
  isTrack,
  flattenGrouplike,
  parentSymbol,
} from './playlist-utils.js'

async function download(item, record) {
  if (isGroup(item)) {
    // TODO: Download all children (recursively), show a confirmation prompt
    // if there are a lot of items (remember to flatten).
    return
  }

  // You can't download things that aren't tracks!
  if (!isTrack(item)) {
    return
  }

  // Don't start downloading an item if we're already downloading it!
  if (record.downloading) {
    return
  }

  const arg = item.downloaderArg
  record.downloading = true
  try {
    return await getDownloaderFor(arg)(arg)
  } finally {
    record.downloading = false
  }
}

class QueuePlayer extends EventEmitter {
  constructor({
    getPlayer,
    getRecordFor
  }) {
    super()

    this.player = null
    this.playingTrack = null
    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
    this.pauseNextTrack = false
    this.queueEndMode = 'end' // end, loop, shuffle
    this.playedTrackToEnd = false
    this.timeData = null

    this.getPlayer = getPlayer
    this.getRecordFor = getRecordFor
  }

  async setup() {
    this.player = await this.getPlayer()

    if (!this.player) {
      return {
        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
      }
    }

    this.player.on('printStatusLine', data => {
      if (this.playingTrack) {
        const oldTimeData = this.timeData
        this.timeData = data
        this.emit('received time data', data, oldTimeData, this)
      }
    })

    return true
  }

  queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
    const { items } = this.queueGrouplike
    const newTrackIndex = items.length

    // The position which new tracks should be added at, if afterItem is
    // passed.
    const afterIndex = afterItem && items.indexOf(afterItem)

    // Keeps track of how many tracks have been added; this is used so that
    // a whole group can be queued in order after a given item.
    let grouplikeOffset = 0

    // Keeps track of how many tracks have been removed (times -1); this is
    // used so we queue tracks at the intended spot.
    let removeOffset = 0

    const recursivelyAddTracks = item => {
      // For groups, just queue all children.
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyAddTracks(child)
        }

        return
      }

      // If the item isn't a track, it can't be queued.
      if (!isTrack(item)) {
        return
      }

      // You can't put the same track in the queue twice - we automatically
      // remove the old entry. (You can't for a variety of technical reasons,
      // but basically you either have the display all bork'd, or new tracks
      // can't be added to the queue in the right order (because Object.assign
      // is needed to fix the display, but then you end up with a new object
      // that doesn't work with indexOf).)
      if (items.includes(item)) {
        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
        // is the one that's currently playing, we won't do anything with it
        // at all.
        if (!movePlayingTrack && item === this.playingTrack) {
          return
        }

        const removeIndex = items.indexOf(item)
        items.splice(removeIndex, 1)

        // If the item we removed was positioned before the insertion index,
        // we need to shift that index back one, so it's placed after the same
        // intended track.
        if (removeIndex <= afterIndex) {
          removeOffset--
        }
      }

      if (afterItem === 'FRONT') {
        items.unshift(item)
      } else if (afterItem) {
        items.splice(afterIndex + 1 + grouplikeOffset + removeOffset, 0, item)
      } else {
        items.push(item)
      }

      grouplikeOffset++
    }

    recursivelyAddTracks(topItem)
    this.emitQueueUpdated()

    // This is the first new track, if a group was queued.
    const newTrack = items[newTrackIndex]

    return newTrack
  }

  distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
    if (isTrack(grouplike)) {
      grouplike = {items: [grouplike]}
    }

    const { items } = this.queueGrouplike
    const newTracks = flattenGrouplike(grouplike).items.filter(isTrack)

    // Expressly do an initial pass and unqueue the items we want to queue -
    // otherwise they would mess with the math we do afterwords.
    for (const item of newTracks) {
      if (items.includes(item)) {
        /*
        if (!movePlayingTrack && item === this.playingTrack) {
          // NB: if uncommenting this code, splice item from newTracks and do
          // continue instead of return!
          return
        }
        */
        items.splice(items.indexOf(item), 1)
      }
    }

    const distributeStart = items.indexOf(this.playingTrack) + 1

    let distributeEnd
    if (rangeEnd === 'end-of-queue') {
      distributeEnd = items.length
    } else if (typeof rangeEnd === 'number') {
      distributeEnd = Math.min(items.length, rangeEnd)
    } else {
      throw new Error('Invalid rangeEnd: ' + rangeEnd)
    }

    const distributeSize = distributeEnd - distributeStart

    if (how === 'evenly') {
      let offset = 0
      for (const item of newTracks) {
        const insertIndex = distributeStart + Math.floor(offset)
        items.splice(insertIndex, 0, item)
        offset++
        offset += distributeSize / newTracks.length
      }
    } else if (how === 'randomly') {
      const indexes = newTracks.map(() => Math.floor(Math.random() * distributeSize))
      indexes.sort()
      for (let i = 0; i < newTracks.length; i++) {
        const item = newTracks[i]
        const insertIndex = distributeStart + indexes[i] + i
        items.splice(insertIndex, 0, item)
      }
    }

    this.emitQueueUpdated()
  }

  unqueue(topItem, focusItem = null) {
    // This function has support to unqueue groups - it removes all tracks in
    // the group recursively. (You can never unqueue a group itself from the
    // queue listing because groups can't be added directly to the queue.)

    const { items } = this.queueGrouplike

    const recursivelyUnqueueTracks = item => {
      // For groups, just unqueue all children. (Groups themselves can't be
      // added to the queue, so we don't need to worry about removing them.)
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyUnqueueTracks(child)
        }

        return
      }

      // Don't unqueue the currently-playing track - this usually causes more
      // trouble than it's worth.
      if (item === this.playingTrack) {
        return
      }

      // If we're unqueueing the item which is currently focused by the cursor,
      // just move the cursor ahead.
      if (item === focusItem) {
        focusItem = items[items.indexOf(focusItem) + 1]
        // ...Unless that puts it at past the end of the list, in which case, move
        // it behind the item we're removing.
        if (!focusItem) {
          focusItem = items[items.length - 2]
        }
      }

      if (items.includes(item)) {
        items.splice(items.indexOf(item), 1)
      }
    }

    recursivelyUnqueueTracks(topItem)
    this.emitQueueUpdated()

    return focusItem
  }

  clearQueuePast(track) {
    const { items } = this.queueGrouplike
    const index = items.indexOf(track) + 1

    if (index < 0) {
      return
    } else if (index < items.indexOf(this.playingTrack)) {
      items.splice(index, items.length - index, this.playingTrack)
    } else {
      items.splice(index)
    }

    this.emitQueueUpdated()
  }

  clearQueueUpTo(track) {
    const { items } = this.queueGrouplike
    const endIndex = items.indexOf(track)
    const startIndex = (this.playingTrack ? items.indexOf(this.playingTrack) + 1 : 0)

    if (endIndex < 0) {
      return
    } else if (endIndex < startIndex) {
      return
    } else {
      items.splice(startIndex, endIndex - startIndex)
    }

    this.emitQueueUpdated()
  }

  playSooner(item) {
    this.distributeQueue(item, {
      how: 'randomly',
      rangeEnd: this.queueGrouplike.items.indexOf(item)
    })
  }

  playLater(item) {
    this.skipIfCurrent(item)
    this.distributeQueue(item, {
      how: 'randomly'
    })
  }

  skipIfCurrent(track) {
    if (track === this.playingTrack) {
      this.playNext(track)
    }
  }

  shuffleQueue(pastPlayingTrackOnly = true) {
    const queue = this.queueGrouplike
    const index = (pastPlayingTrackOnly
      ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
      : 0)
    const initialItems = queue.items.slice(0, index)
    const remainingItems = queue.items.slice(index)
    const newItems = initialItems.concat(shuffleArray(remainingItems))
    queue.items = newItems
    this.emitQueueUpdated()
  }

  clearQueue() {
    // Clear the queue so that there aren't any items left in it (except for
    // the track that's currently playing).
    this.queueGrouplike.items = this.queueGrouplike.items
      .filter(item => item === this.playingTrack)
    this.emitQueueUpdated()
  }

  emitQueueUpdated() {
    this.emit('queue updated')
  }

  async stopPlaying() {
    // We emit this so the active play() call doesn't immediately start a new
    // track. We aren't *actually* about to play a new track.
    this.emit('playing new track')
    await this.player.kill()
    this.clearPlayingTrack()
  }


  async play(item, startTime = 0) {
    if (this.player === null) {
      throw new Error('Attempted to play before a player was loaded')
    }

    let playingThisTrack = true
    this.emit('playing new track')
    this.once('playing new track', () => {
      playingThisTrack = false
    })

    // If it's a group, play the first track.
    if (isGroup(item)) {
      item = flattenGrouplike(item).items[0]
    }

    // If there is no item (e.g. an empty group), well.. don't do anything.
    if (!item) {
      return
    }

    // If it's not a track, you can't play it.
    if (!isTrack(item)) {
      return
    }

    playTrack: {
      // No downloader argument? That's no good - stop here.
      // TODO: An error icon on this item, or something???
      if (!item.downloaderArg) {
        break playTrack
      }

      // If, by the time the track is downloaded, we're playing something
      // different from when the download started, assume that we just want to
      // keep listening to whatever new thing we started.

      const oldTrack = this.playingTrack

      const downloadFile = await this.download(item)

      if (this.playingTrack !== oldTrack) {
        return
      }

      this.timeData = null
      this.playingTrack = item
      this.emit('playing', this.playingTrack, oldTrack, startTime, this)

      await this.player.kill()
      if (this.playedTrackToEnd) {
        this.player.setPause(this.pauseNextTrack)
        this.pauseNextTrack = false
        this.playedTrackToEnd = false
      } else {
        this.player.setPause(false)
      }
      await this.player.playFile(downloadFile, startTime)
    }

    // playingThisTrack now means whether the track played through to the end
    // (true), or was stopped by a different track being started (false).

    if (playingThisTrack) {
      this.playedTrackToEnd = true
      this.playNext(item)
    }
  }

  playNext(track, automaticallyQueueNextTrack = false) {
    if (!track) return false

    // Auto-queue is nice but it should only happen when the queue hasn't been
    // explicitly set to loop.
    automaticallyQueueNextTrack = (
      automaticallyQueueNextTrack &&
      this.queueEndMode === 'end')

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) return false
    queueIndex++

    if (queueIndex >= queue.items.length) {
      if (automaticallyQueueNextTrack) {
        const parent = track[parentSymbol]
        if (!parent) return false
        let index = parent.items.indexOf(track)
        let nextItem
        do {
          nextItem = parent.items[++index]
        } while (nextItem && !(isTrack(nextItem) || isGroup(nextItem)))
        if (!nextItem) return false
        this.queue(nextItem)
        queueIndex = queue.items.length - 1
      } else {
        return this.playNextAtQueueEnd()
      }
    }

    this.play(queue.items[queueIndex])
    return true
  }

  playPrevious(track, automaticallyQueuePreviousTrack = false) {
    if (!track) return false

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) return false
    queueIndex--

    if (queueIndex < 0) {
      if (automaticallyQueuePreviousTrack) {
        const parent = track[parentSymbol]
        if (!parent) return false
        let index = parent.items.indexOf(track)
        let previousItem
        do {
          previousItem = parent.items[--index]
        } while (previousItem && !(isTrack(previousItem) || isGroup(previousItem)))
        if (!previousItem) return false
        this.queue(previousItem, 'FRONT')
        queueIndex = 0
      } else {
        return false
      }
    }

    this.play(queue.items[queueIndex])
    return true
  }

  playFirst() {
    const queue = this.queueGrouplike
    if (queue.items.length) {
      this.play(queue.items[0])
      return true
    }
    return false
  }

  playNextAtQueueEnd() {
    switch (this.queueEndMode) {
      case 'loop':
        this.playFirst()
        return true
      case 'shuffle':
        this.shuffleQueue(false)
        this.playFirst()
        return true
      case 'end':
      default:
        this.clearPlayingTrack()
        return false
    }
  }

  async playOrSeek(item, time) {
    if (!isTrack(item)) {
      // This only makes sense to call with individual tracks!
      return
    }

    if (item === this.playingTrack) {
      this.seekTo(time)
    } else {
      // Queue the track, but only if it's not already in the queue, so that we
      // respect an existing queue order.
      const queue = this.queueGrouplike
      const queueIndex = queue.items.indexOf(item)
      if (queueIndex === -1) {
        this.queue(item, this.playingTrack)
      }

      this.play(item, time)
    }
  }

  clearPlayingTrack() {
    if (this.playingTrack !== null) {
      const oldTrack = this.playingTrack
      this.playingTrack = null
      this.timeData = null
      this.emit('playing', null, oldTrack, 0, this)
    }
  }

  async download(item) {
    return download(item, this.getRecordFor(item))
  }

  seekAhead(seconds) {
    this.player.seekAhead(seconds)
  }

  seekBack(seconds) {
    this.player.seekBack(seconds)
  }

  seekTo(seconds) {
    this.player.seekTo(seconds)
  }

  seekToStart() {
    this.player.seekToStart()
  }

  togglePause() {
    this.player.togglePause()
  }

  setPause(value) {
    this.player.setPause(value)
  }

  toggleLoop() {
    this.player.toggleLoop()
  }

  setLoop(value) {
    this.player.setLoop(value)
  }

  volUp(amount = 10) {
    this.player.volUp(amount)
  }

  volDown(amount = 10) {
    this.player.volDown(amount)
  }

  setVolume(value) {
    this.player.setVolume(value)
  }

  setVolumeMultiplier(value) {
    this.player.setVolumeMultiplier(value);
  }

  fadeIn() {
    return this.player.fadeIn();
  }

  setPauseNextTrack(value) {
    this.pauseNextTrack = !!value
  }

  setLoopQueueAtEnd(value) {
    this.loopQueueAtEnd = !!value
    this.emit('set-loop-queue-at-end', !!value)
  }

  get remainingTracks() {
    const index = this.queueGrouplike.items.indexOf(this.playingTrack)
    const length = this.queueGrouplike.items.length
    if (index === -1) {
      return length
    } else {
      return length - index - 1
    }
  }

  get playSymbol() {
    if (this.player && this.playingTrack) {
      if (this.player.isPaused) {
        return '⏸'
      } else {
        return '▶'
      }
    } else {
      return '.'
    }
  }
}

export default class Backend extends EventEmitter {
  constructor({
    playerName = null,
    playerOptions = []
  } = {}) {
    super()

    this.playerName = playerName;
    this.playerOptions = playerOptions;

    if (playerOptions.length && !playerName) {
      throw new Error(`Must specify playerName to specify playerOptions`);
    }

    this.queuePlayers = []

    this.recordStore = new RecordStore()
    this.throttleMetadata = throttlePromise(10)
    this.metadataDictionary = {}

    this.rootDirectory = os.homedir() + '/.mtui'
    this.metadataPath = this.rootDirectory + '/track-metadata.json'
  }

  async setup() {
    const error = await this.addQueuePlayer()
    if (error.error) {
      return error
    }

    await this.loadMetadata()

    return true
  }

  async addQueuePlayer() {
    const queuePlayer = new QueuePlayer({
      getPlayer: () => getPlayer(this.playerName, this.playerOptions),
      getRecordFor: item => this.getRecordFor(item)
    })

    const error = await queuePlayer.setup()
    if (error.error) {
      return error
    }

    this.queuePlayers.push(queuePlayer)
    this.emit('added queue player', queuePlayer)

    for (const event of [
      'playing',
      'done playing',
      'queue',
      'distribute-queue',
      'unqueue',
      'clear-queue-past',
      'clear-queue-up-to',
      'shuffle-queue',
      'clear-queue',
      'queue updated',
      'seek-ahead',
      'seek-back',
      'toggle-pause',
      'set-pause',
      'toggle-loop',
      'set-loop',
      'vol-up',
      'vol-down',
      'set-volume',
      'set-pause-next-track',
      'set-loop-queue-at-end'
    ]) {
      queuePlayer.on(event, (...data) => {
        this.emit(event, queuePlayer, ...data)
      })
    }

    return queuePlayer
  }

  removeQueuePlayer(queuePlayer) {
    if (this.queuePlayers.length > 1) {
      this.queuePlayers.splice(this.queuePlayers.indexOf(queuePlayer), 1)
      this.emit('removed queue player', queuePlayer)
    }
  }

  async readMetadata() {
    try {
      return JSON.parse(await readFile(this.metadataPath))
    } catch (error) {
      // Just stop. It's okay to fail to load metadata.
      return null
    }
  }

  async loadMetadata() {
    Object.assign(this.metadataDictionary, await this.readMetadata())
  }

  async saveMetadata() {
    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
    await writeFile(this.metadataPath, JSON.stringify(newData))
  }

  getMetadataFor(item) {
    const key = this.metadataDictionary[item.downloaderArg]
    return this.metadataDictionary[key] || null
  }

  async processMetadata(item, reprocess = false, top = true) {
    let counter = 0

    if (isGroup(item)) {
      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
      counter += results.reduce((acc, n) => acc + n, 0)
    } else if (isTrack(item)) process: {
      if (!reprocess && this.getMetadataFor(item)) {
        break process
      }

      await this.throttleMetadata(async () => {
        const filePath = await this.download(item)
        const metadataReader = getMetadataReaderFor(filePath)
        const data = await metadataReader(filePath)

        this.metadataDictionary[item.downloaderArg] = filePath
        this.metadataDictionary[filePath] = data
      })

      this.emit('processMetadata progress', this.throttleMetadata.queue.length)

      counter++
    }

    if (top) {
      await this.saveMetadata()
    }

    return counter
  }

  getRecordFor(item) {
    return this.recordStore.getRecord(item)
  }

  getDuration(item) {
    let noticedMissingMetadata = false

    const durationFn = (acc, track) => {
      const metadata = this.getMetadataFor(track)
      if (!metadata) noticedMissingMetadata = true
      return acc + (metadata && metadata.duration) || 0
    }

    let items
    if (isGroup(item)) {
      items = flattenGrouplike(item).items
    } else {
      items = [item]
    }

    const tracks = items.filter(isTrack)

    const seconds = tracks.reduce(durationFn, 0)

    let { duration: string } = getTimeStringsFromSec(0, seconds)
    const approxSymbol = noticedMissingMetadata ? '+' : ''
    string += approxSymbol

    return {seconds, string, noticedMissingMetadata, approxSymbol}
  }

  async stopPlayingAll() {
    for (const queuePlayer of this.queuePlayers) {
      await queuePlayer.stopPlaying()
    }
  }

  async download(item) {
    return download(item, this.getRecordFor(item))
  }
}
